<?php
/**
 * li₃: the most RAD framework for PHP (http://li3.me)
 *
 * Copyright 2016, Union of RAD. All rights reserved. This source
 * code is distributed under the terms of the BSD 3-Clause License.
 * The full license text can be found in the LICENSE.txt file.
 */

namespace lithium\data\entity;

use RuntimeException;

/**
 * `Document` is an alternative to the `entity\Record` class, which is optimized for
 * organizing collections of entities from document-oriented databases such as CouchDB
 * or MongoDB. A `Document` object's fields can represent a collection of both simple
 * and complex data types, as well as other `Document` objects. Given the following data
 * (document) structure:
 *
 * ```json
 * {
 * 	_id: 12345.
 * 	name: 'Acme, Inc.',
 * 	employees: {
 * 		'Larry': { email: 'larry@acme.com' },
 * 		'Curly': { email: 'curly@acme.com' },
 * 		'Moe': { email: 'moe@acme.com' }
 * 	}
 * }
 * ```
 *
 * You can query the object as follows:
 * ```
 * $acme = Company::find(12345);
 * ```
 *
 * This returns a `Document` object, populated with the raw representation of the data.
 * ```
 * print_r($acme->to('array'));
 *
 * // Yields:
 * //	[
 * //	'_id' => 12345,
 * //	'name' => 'Acme, Inc.',
 * //	'employees' => [
 * //		'Larry' => ['email' => 'larry@acme.com'],
 * //		'Curly' => ['email' => 'curly@acme.com'],
 * //		'Moe' => ['email' => 'moe@acme.com']
 * //	]
 * //]
 * ```
 *
 * As with other database objects, a `Document` exposes its fields as object properties, like so:
 * ```
 * echo $acme->name; // echoes 'Acme, Inc.'
 * ```
 *
 * However, accessing a field containing a data set will return that data set wrapped in a
 * sub-`Document` object., i.e.:
 * ```
 * $employees = $acme->employees;
 * // returns a Document object with the data in 'employees'
 * ```
 */
class Document extends \lithium\data\Entity implements \Iterator, \ArrayAccess {

	/**
	 * If this `Document` instance has a parent document (see `$_parent`), this value indicates
	 * the key name of the parent document that contains it.
	 *
	 * @see lithium\data\entity\Document::$_parent
	 * @var string
	 */
	protected $_pathKey = null;

	/**
	 * Contains an array of backend-specific statistics generated by the query that produced this
	 * `Document` object. These stats are accessible via the `stats()` method.
	 *
	 * @see lithium\data\collection\DocumentSet::stats()
	 * @var array
	 */
	protected $_stats = [];

	/**
	 * Holds the current iteration state. Used by `Document::valid()` to terminate `foreach` loops
	 * when there are no more fields to iterate over.
	 *
	 * @var boolean
	 */
	protected $_valid = false;

	/**
	 * Removed keys list. Contains names of the fields will be removed from the backend data store
	 *
	 * @var array
	 */
	protected $_removed = [];

	protected function _init() {
		parent::_init();

		$data = (array) $this->_data;
		$this->_data = [];
		$this->_updated = [];
		$this->_removed = [];

		$this->_handlers += [
			'MongoId' => function($value) { return (string) $value; },
			'MongoDate' => function($value) { return $value->sec; }
		];

		$this->set($data, ['init' => true]);
		$this->sync(null, [], ['materialize' => $this->_exists]);
		unset($this->_autoConfig);
	}

	/**
	 * PHP magic method used when accessing fields as document properties, i.e. `$document->_id`.
	 *
	 * @param $name The field name, as specified with an object property.
	 * @return mixed Returns the value of the field specified in `$name`, and wraps complex data
	 *         types in sub-`Document` objects.
	 */
	public function &__get($name) {
		if (strpos($name, '.')) {
			return $this->_getNested($name);
		}

		if (isset($this->_embedded[$name]) && !isset($this->_relationships[$name])) {
			throw new RuntimeException("Not implemented.");
		}
		$result =& parent::__get($name);

		if ($result !== null || array_key_exists($name, $this->_updated)) {
			return $result;
		}

		if ($field = $this->schema($name)) {
			if (isset($field['default'])) {
				$this->set([$name => $field['default']]);
				return $this->_updated[$name];
			}
			if (isset($field['array']) && $field['array'] && ($model = $this->_model)) {
				$this->_updated[$name] = $model::create([], [
					'class' => 'set',
					'schema' => $this->schema(),
					'pathKey' => $this->_pathKey ? $this->_pathKey . '.' . $name : $name,
					'parent' => $this,
					'model' => $this->_model,
					'defaults' => false
				]);
				return $this->_updated[$name];
			}
		}
		$null = null;
		return $null;
	}

	public function export(array $options = []) {
		foreach ($this->_updated as $key => $val) {
			if ($val instanceof self) {
				$path = $this->_pathKey ? "{$this->_pathKey}." : '';
				$this->_updated[$key]->_pathKey = "{$path}{$key}";
			}
		}
		return parent::export($options) + [
			'key' => $this->_pathKey,
			'remove' => $this->_removed
		];
	}

	/**
	 * Extends the parent implementation to ensure that child documents are properly synced as well.
	 *
	 * @param mixed $id
	 * @param array $data
	 * @param array $options Options when calling this method:
	 *              - `'recursive'` _boolean_: If `true` attempts to sync nested objects as well.
	 *                Otherwise, only syncs the current object. Defaults to `true`.
	 * @return void
	 */
	public function sync($id = null, array $data = [], array $options = []) {
		$defaults = ['recursive' => true];
		$options += $defaults;

		if (!$options['recursive']) {
			return parent::sync($id, $data, $options);
		}

		foreach ($this->_updated as $key => $val) {
			if (is_object($val) && method_exists($val, 'sync')) {
				$nested = isset($data[$key]) ? $data[$key] : [];
				$this->_updated[$key]->sync(null, $nested, $options);
			}
		}
		parent::sync($id, $data, $options);
	}

	/**
	 * Instantiates a new `Document` object as a descendant of the current object, and sets all
	 * default values and internal state.
	 *
	 * @param string $classType The type of class to create, either `'entity'` or `'set'`.
	 * @param string $key The key name to which the related object is assigned.
	 * @param array $data The internal data of the related object.
	 * @param array $options Any other options to pass when instantiating the related object.
	 * @return object Returns a new `Document` object instance.
	 */
	protected function _relation($classType, $key, $data, $options = []) {
		return parent::_relation($classType, $key, $data, ['exists' => false] + $options);
	}

	protected function &_getNested($name) {
		$current = $this;
		$null = null;
		$path = explode('.', $name);
		$length = count($path) - 1;

		foreach ($path as $i => $key) {
			if (!isset($current[$key])) {
				return $null;
			}
			$current = $current[$key];

			if (is_scalar($current) && $i < $length) {
				return $null;
			}
		}
		return $current;
	}

	/**
	 * PHP magic method used when setting properties on the `Document` instance, i.e.
	 * `$document->title = 'Lorem Ipsum'`. If `$value` is a complex data type (i.e. associative
	 * array), it is wrapped in a sub-`Document` object before being appended.
	 *
	 * @param $name The name of the field/property to write to, i.e. `title` in the above example.
	 * @param $value The value to write, i.e. `'Lorem Ipsum'`.
	 * @return void
	 */
	public function __set($name, $value = null) {
		$this->set([$name => $value]);
	}

	protected function _setNested($name, $value) {
		$current =& $this;
		$path = explode('.', $name);
		$length = count($path) - 1;

		for ($i = 0; $i < $length; $i++) {
			$key = $path[$i];

			if (isset($current[$key])) {
				$next =& $current[$key];
			} else {
				unset($next);
				$next = null;
			}

			if ($next === null && ($model = $this->_model)) {
				$current->set([$key => $model::create([], ['defaults' => false])]);
				$next =& $current->{$key};
			}
			$current =& $next;
		}

		if (is_object($current)) {
			$current->set([end($path) => $value]);
		}
	}

	/**
	 * PHP magic method used to check the presence of a field as document properties, i.e.
	 * `$document->_id`.
	 *
	 * @param $name The field name, as specified with an object property.
	 * @return boolean True if the field specified in `$name` exists, false otherwise.
	 */
	public function __isset($name) {
		if (strpos($name, '.')) {
			return $this->_getNested($name) !== null;
		}
		return isset($this->_updated[$name]);
	}

	/**
	 * PHP magic method used when unset() is called on a `Document` instance.
	 *
	 * Use case for this would be when you wish to edit a document and remove a field, ie.:
	 * ```
	 * $doc = Posts::find($id);
	 * unset($doc->fieldName);
	 * $doc->save();
	 * ```
	 *
	 * @param string $name The name of the field to remove.
	 * @return void
	 */
	public function __unset($name) {
		$parts = explode('.', $name, 2);

		if (isset($parts[1])) {
			unset($this->{$parts[0]}[$parts[1]]);
		} else {
			unset($this->_updated[$name]);
			$this->_removed[$name] = true;
		}
	}

	/**
	 * Allows several properties to be assigned at once.
	 *
	 * For example:
	 * ```
	 * $doc->set(['title' => 'Lorem Ipsum', 'value' => 42]);
	 * ```
	 *
	 * @param array $data An associative array of fields and values to assign to the `Document`.
	 * @param array $options
	 * @return void
	 */
	public function set(array $data, array $options = []) {
		$defaults = ['init' => false];
		$options += $defaults;

		$cast = ($schema = $this->schema());

		foreach ($data as $key => $val) {
			unset($this->_increment[$key]);
			if (strpos($key, '.')) {
				$this->_setNested($key, $val);
				continue;
			}
			if ($cast) {
				$pathKey = $this->_pathKey;
				$model = $this->_model;
				$parent = $this;
				$val = $schema->cast($this, $key, $val, compact('pathKey', 'model', 'parent'));
			}
			if ($val instanceof self) {
				$val->_exists = $options['init'] && $this->_exists;
				$val->_pathKey = ($this->_pathKey ? "{$this->_pathKey}." : '') . $key;
				$val->_model = $val->_model ?: $this->_model;
				$val->_schema = $val->_schema ?: $this->_schema;
			}
			$this->_updated[$key] = $val;
		}
	}

	/**
	 * Allows document fields to be accessed as array keys, i.e. `$document['_id']`.
	 *
	 * @param mixed $offset String or integer indicating the offset or index of a document in a set,
	 *              or the name of a field in an individual document.
	 * @return mixed Returns either a sub-object in the document, or a scalar field value.
	 */
	public function offsetGet($offset) {
		return $this->__get($offset);
	}

	/**
	 * Allows document fields to be assigned as array keys, i.e. `$document['_id'] = $id`.
	 *
	 * @param mixed $offset String or integer indicating the offset or the name of a field in an
	 *              individual document.
	 * @param mixed $value The value to assign to the field.
	 * @return void
	 */
	public function offsetSet($offset, $value) {
		return $this->set([$offset => $value]);
	}

	/**
	 * Allows document fields to be tested as array keys, i.e. `isset($document['_id'])`.
	 *
	 * @param mixed $offset String or integer indicating the offset or the name of a field in an
	 *              individual document.
	 * @return boolean Returns `true` if `$offset` is a field in the document, otherwise `false`.
	 */
	public function offsetExists($offset) {
		return $this->__isset($offset);
	}

	/**
	 * Allows document fields to be unset as array keys, i.e. `unset($document['_id'])`.
	 *
	 * @param string $key The name of a field in an individual document.
	 * @return void
	 */
	public function offsetUnset($key) {
		return $this->__unset($key);
	}

	/**
	 * Rewinds to the first item.
	 *
	 * @return mixed The current item after rewinding.
	 */
	public function rewind() {
		reset($this->_data);
		reset($this->_updated);
		$this->_valid = (count($this->_updated) > 0);
		return current($this->_updated);
	}

	/**
	 * Used by the `Iterator` interface to determine the current state of the iteration, and when
	 * to stop iterating.
	 *
	 * @return boolean
	 */
	public function valid() {
		return $this->_valid;
	}

	public function current() {
		$current = current($this->_data);
		return isset($this->_removed[key($this->_data)]) ? null : $current;
	}

	public function key() {
		$key = key($this->_data);
		return isset($this->_removed[$key]) ? false : $key;
	}

	/**
	 * Adds conversions checks to ensure certain class types and embedded values are properly cast.
	 *
	 * @param string $format Currently only `array` is supported.
	 * @param array $options
	 * @return mixed
	 */
	public function to($format, array $options = []) {
		$options['internal'] = false;
		return parent::to($format, $options);
	}

	/**
	 * Returns the next `Document` in the set, and advances the object's internal pointer. If the
	 * end of the set is reached, a new document will be fetched from the data source connection
	 * handle (`$_handle`). If no more records can be fetched, returns `null`.
	 *
	 * @return mixed Returns the next record in the set, or `null`, if no more records are
	 *         available.
	 */
	public function next() {
		$prev = key($this->_data);
		$this->_valid = (next($this->_data) !== false);
		$cur = key($this->_data);

		if (isset($this->_removed[$cur])) {
			return $this->next();
		}
		if (!$this->_valid && $cur !== $prev && $cur !== null) {
			$this->_valid = true;
		}
		return $this->_valid ? $this->__get(key($this->_data)) : null;
	}
}

?>